 

package com.junjie.shortlink.project.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.Week;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.text.StrBuilder;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;

import com.junjie.shortlink.project.common.convention.exception.ClientException;
import com.junjie.shortlink.project.common.convention.exception.ServiceException;
import com.junjie.shortlink.project.common.enums.VailDateTypeEnum;
import com.junjie.shortlink.project.config.GotoDomainWhiteListConfiguration;
import com.junjie.shortlink.project.dao.entity.*;
import com.junjie.shortlink.project.dao.mapper.*;
import com.junjie.shortlink.project.dto.biz.ShortLinkStatsRecordDTO;
import com.junjie.shortlink.project.dto.req.ShortLinkBatchCreateReqDTO;
import com.junjie.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.junjie.shortlink.project.dto.req.ShortLinkPageReqDTO;
import com.junjie.shortlink.project.dto.req.ShortLinkUpdateReqDTO;
import com.junjie.shortlink.project.dto.resp.*;
import com.junjie.shortlink.project.mq.producer.DelayShortLinkStatsProducer;

import com.junjie.shortlink.project.mq.producer.ShortLinkStatsSaveRedisProducer;
import com.junjie.shortlink.project.service.LinkStatsTodayService;
import com.junjie.shortlink.project.service.ShortLinkService;
import com.junjie.shortlink.project.toolkit.HashUtil;
import com.junjie.shortlink.project.toolkit.LinkUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.GZIPOutputStream;

import static com.junjie.shortlink.project.common.constant.RedisKeyConstant.*;
import static com.junjie.shortlink.project.common.constant.ShortLinkConstant.AMAP_REMOTE_URL;


/**
 * 短链接接口实现层
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {

    private final RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter;
    private final ShortLinkGotoMapper shortLinkGotoMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private final RedissonClient redissonClient;
    private final LinkAccessStatsMapper linkAccessStatsMapper;
    private final LinkLocaleStatsMapper linkLocaleStatsMapper;
    private final LinkOsStatsMapper linkOsStatsMapper;
    private final LinkBrowserStatsMapper linkBrowserStatsMapper;
    private final LinkDeviceStatsMapper linkDeviceStatsMapper;
    private final LinkAccessLogsMapper linkAccessLogsMapper;
    private final LinkNetworkStatsMapper linkNetworkStatsMapper;
    private final LinkStatsTodayMapper linkStatsTodayMapper;
    private final LinkStatsTodayService linkStatsTodayService;
    private final DelayShortLinkStatsProducer delayShortLinkStatsProducer;
    private final GotoDomainWhiteListConfiguration gotoDomainWhiteListConfiguration;
    private final ShortLinkStatsSaveRedisProducer shortLinkStatsSaveProducer;

    @Value("${short-link.stats.locale.amap-key}")
    private String statsLocaleAmapKey;

    @Value("${short-link.domain.default}")
    private String createShortLinkDefaultDomain;

    @Transactional(rollbackFor = Exception.class)
    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        // 校验原链接是否在白名单中
        verificationWhitelist(requestParam.getOriginUrl());
        // 生成短链接
        String shortLinkSuffix = generatorSuffix(requestParam);
        // 拼接成完整短链接
        String fullShortUrl = StrBuilder.create(createShortLinkDefaultDomain)
                .append("/")
                .append(shortLinkSuffix)
                .toString();
        // 构建短链接对象
        ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                .domain(createShortLinkDefaultDomain)
                .originUrl(requestParam.getOriginUrl())
                .gid(requestParam.getGid())
                .createdType(requestParam.getCreatedType())
                .validDateType(requestParam.getValidDateType())
                .validDate(requestParam.getValidDate())
                .describe(requestParam.getDescribe())
                .shortUri(shortLinkSuffix)
                .enableStatus(0)
                .totalPv(0)
                .totalUv(0)
                .totalUip(0)
                .delTime(0L)
                .fullShortUrl(fullShortUrl)
                .favicon(getFavicon(requestParam.getOriginUrl()))
                .build();
        // 路由表对象
        ShortLinkGotoDO linkGotoDO = ShortLinkGotoDO.builder()
                .fullShortUrl(fullShortUrl)
                .gid(requestParam.getGid())
                .build();
        // 这里因为数据库设置了唯一索引，所以如果重复入库会报错
        // 这里重复入库的情况就是布隆过滤器误判
        try{
            baseMapper.insert(shortLinkDO);
            // 路由表插入对应路径
            shortLinkGotoMapper.insert(linkGotoDO);
        }catch (DuplicateKeyException ex){
            // keypoint 误判的短链接如何处理 一般布隆过滤器满了误判概率才大，否则数据量小是很难误判的
            // 第一种，短链接真实存在于缓存中
            // 第二种，短链接不一定存在缓存中
            // 这里我们发现重复了才抛异常
//            LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
//                    .eq(ShortLinkDO::getFullShortUrl, fullShortUrl);
//            ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
//            if(hasShortLinkDO != null){
//                log.warn("短链接:{} 重复入库",fullShortUrl);
//                throw new ServiceException("短链接生成重复");
//            }

            // keypoint 这是我们相信布隆不存在则短链接一定不存在的情况下，不过高并发情况下也可能重复，但
            //  是也没必要向上面一样再次查询一下数据库是否存在，因为已经触发了唯一索引，没必要查了
            throw new ServiceException(String.format("短链接：%s 生成重复", fullShortUrl));
        }
        // 新的数据加入布隆过滤器 而且这里要加的是fullShortUrl
        shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
        // 将要跳转的原始路径放入缓存中，相当于缓存预热 然后这里设置缓存过期时间是用的毫秒
        stringRedisTemplate.opsForValue()
                .set(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl),requestParam.getOriginUrl()
                        , LinkUtil.getLinkCacheValidTime(requestParam.getValidDate()),TimeUnit.MILLISECONDS);
        return ShortLinkCreateRespDTO.builder()
                .gid(requestParam.getGid())
                .fullShortUrl("http://" + shortLinkDO.getFullShortUrl())
                .originUrl(requestParam.getOriginUrl())
                .build();
    }

    @Override
    public IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkPageReqDTO requestParam) {
//        LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
//                .eq(ShortLinkDO::getGid, requestParam.getGid())
//                .eq(ShortLinkDO::getEnableStatus, 0)
//                .eq(ShortLinkDO::getDelFlag, 0)
//                .orderByDesc(ShortLinkDO::getCreateTime);
//        IPage<ShortLinkDO> resultPage = baseMapper.selectPage(requestParam, queryWrapper);

        // 使用SQL查询 与today表联表
        IPage<ShortLinkDO> resultPage = baseMapper.pageLink(requestParam);
        return resultPage.convert(each -> {
            ShortLinkPageRespDTO result = BeanUtil.toBean(each, ShortLinkPageRespDTO.class);
            result.setDomain("http://" + result.getDomain());
            return result;
        });
    }

    @Override
    public List<ShortLinkGroupCountQueryRespDTO> listGroupShortLinkCount(List<String> requestParam) {
        QueryWrapper<ShortLinkDO> queryWrapper = Wrappers.query(new ShortLinkDO())
                // 这里要转一下别名
                .select("gid as gid, count(*) as shortLinkCount")
                .in("gid", requestParam)
                .eq("enable_status", 0)
                .eq("del_flag", 0)
                .eq("del_time", 0L)
                .groupBy("gid");
        // 建议selectMaps，如果用selectObject的话，key为0,1,2这种
        List<Map<String, Object>> shortLinkDOList = baseMapper.selectMaps(queryWrapper);
        return BeanUtil.copyToList(shortLinkDOList, ShortLinkGroupCountQueryRespDTO.class);
    }

    /**
     * 修改短链接逻辑非常复杂
     * 首先可以看到这里面操作很多表，这里又开始了事务，事务非常的长
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public void updateShortLink(ShortLinkUpdateReqDTO requestParam) {
        // 校验原链接是否在白名单中
        verificationWhitelist(requestParam.getOriginUrl());
        // keypoint 这里有个问题，因为我们是根据gid也就是短链接分组做的分表，如果我们直接去
        //  修改表中数据的分组ID的话，那么下次根据分组ID来找短链接就找不到了，因为是根据分组ID
        //  去Hash匹配的嘛，所以这里如果要修改分组ID的话，就要先删后增
        // 先从数据库中查出原始分组的短链接
        LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                .eq(ShortLinkDO::getGid, requestParam.getOriginGid())
                .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                .eq(ShortLinkDO::getDelFlag, 0)
                .eq(ShortLinkDO::getEnableStatus, 0);
        ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
        if (hasShortLinkDO == null) {
            throw new ClientException("短链接记录不存在");
        }

        if (Objects.equals(hasShortLinkDO.getGid(), requestParam.getGid())) {
            // 如果分组ID一致，也就是分组ID没有修改，我们就做修改操作
            LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                    .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                    .eq(ShortLinkDO::getGid, requestParam.getGid())
                    .eq(ShortLinkDO::getDelFlag, 0)
                    .eq(ShortLinkDO::getEnableStatus, 0)
                    // 如果前端传递的属性要设置短链接为永久有效，那么有效期就设置为null
                    .set(Objects.equals(requestParam.getValidDateType(), VailDateTypeEnum.PERMANENT.getType()), ShortLinkDO::getValidDate, null);
            // 重新构建修改对象
            ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                    .domain(createShortLinkDefaultDomain)
                    .shortUri(hasShortLinkDO.getShortUri())
                    .favicon(hasShortLinkDO.getFavicon())
                    .createdType(hasShortLinkDO.getCreatedType())
                    .gid(requestParam.getGid())
                    .originUrl(requestParam.getOriginUrl())
                    .describe(requestParam.getDescribe())
                    .validDateType(requestParam.getValidDateType())
                    .validDate(requestParam.getValidDate())
                    .build();
            baseMapper.update(shortLinkDO,updateWrapper);
        }else{

            // 1.0做法，没有迁移其他关联表的数据，因为如果动了gid的话，
            // 其他goto、log之类的表全要改动，而且这里只是单纯的删再增，最新的使用逻辑删除+删除时间
//            // 分组ID不一致，我们就要先删再增
//            LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
//                    .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
//                    // 先用原表的gid拿去删
//                    .eq(ShortLinkDO::getGid, hasShortLinkDO.getGid())
//                    .eq(ShortLinkDO::getDelFlag, 0)
//                    .eq(ShortLinkDO::getEnableStatus, 0);
//            baseMapper.delete(updateWrapper);
//            baseMapper.insert(shortLinkDO);

            // 2.0做法 加入逻辑删除与逻辑删除时间 并且其他关联的goto、log表都做了处理
            // 没有分表的关联表直接修改，分表的关联表先删后增，这样就不怕冲突或者找不到了
            // keypoint 如果是修改gid的话，就必须拿写锁，让其他读/写锁无法拿到，不能说我现在在修改gid，
            //  结果有个人刚好来访问信息，导致他访问的数据是老的数据 且不能两个人同时修改 而且读写锁比
            //  分布式锁更适合这种场景，修改少但是读多，如果是分布式锁的话同时间只有一个人能访问，效率低
            RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(String.format(LOCK_GID_UPDATE_KEY, requestParam.getFullShortUrl()));
            RLock rLock = readWriteLock.writeLock();
            // keypoint 有了rocketMq后，消费组每次只会拉取几条消息，并不会海量并发
            //  ，所以直接同步lock也不会等很久，但是如果该链接一直被访问，
            //  可能会导致写锁一直tryLock不到，反而增加了等待时间，那直接lock排队等候就好了
            rLock.lock();
//            if (!rLock.tryLock()) {
//                throw new ServiceException("短链接正在被访问，请稍后再试...");
//            }

            try {
                LambdaUpdateWrapper<ShortLinkDO> linkUpdateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                        .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                        .eq(ShortLinkDO::getGid, hasShortLinkDO.getGid())
                        .eq(ShortLinkDO::getDelFlag, 0)
                        .eq(ShortLinkDO::getDelTime, 0L)
                        .eq(ShortLinkDO::getEnableStatus, 0);
                ShortLinkDO delShortLinkDO = ShortLinkDO.builder()
                        .delTime(System.currentTimeMillis())
                        .build();
                delShortLinkDO.setDelFlag(1);
                baseMapper.update(delShortLinkDO, linkUpdateWrapper);
                ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                        .domain(createShortLinkDefaultDomain)
                        .originUrl(requestParam.getOriginUrl())
                        .gid(requestParam.getGid())
                        .createdType(hasShortLinkDO.getCreatedType())
                        .validDateType(requestParam.getValidDateType())
                        .validDate(requestParam.getValidDate())
                        .describe(requestParam.getDescribe())
                        .shortUri(hasShortLinkDO.getShortUri())
                        .enableStatus(hasShortLinkDO.getEnableStatus())
                        .totalPv(hasShortLinkDO.getTotalPv())
                        .totalUv(hasShortLinkDO.getTotalUv())
                        .totalUip(hasShortLinkDO.getTotalUip())
                        .fullShortUrl(hasShortLinkDO.getFullShortUrl())
                        .favicon(getFavicon(requestParam.getOriginUrl()))
                        .delTime(0L)
                        .build();
                baseMapper.insert(shortLinkDO);
                LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
                        .eq(ShortLinkGotoDO::getFullShortUrl, requestParam.getFullShortUrl())
                        .eq(ShortLinkGotoDO::getGid, hasShortLinkDO.getGid());
                ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
                shortLinkGotoMapper.delete(linkGotoQueryWrapper);
                shortLinkGotoDO.setGid(requestParam.getGid());
                shortLinkGotoMapper.insert(shortLinkGotoDO);
//                LambdaQueryWrapper<LinkStatsTodayDO> statsTodayQueryWrapper = Wrappers.lambdaQuery(LinkStatsTodayDO.class)
//                        .eq(LinkStatsTodayDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkStatsTodayDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkStatsTodayDO::getDelFlag, 0);
//                List<LinkStatsTodayDO> linkStatsTodayDOList = linkStatsTodayMapper.selectList(statsTodayQueryWrapper);
//                if (CollUtil.isNotEmpty(linkStatsTodayDOList)) {
//                    linkStatsTodayMapper.deleteBatchIds(linkStatsTodayDOList.stream()
//                            .map(LinkStatsTodayDO::getId)
//                            .toList()
//                    );
//                    linkStatsTodayDOList.forEach(each -> each.setGid(requestParam.getGid()));
//                    linkStatsTodayService.saveBatch(linkStatsTodayDOList);
//                }
//                LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
//                        .eq(ShortLinkGotoDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(ShortLinkGotoDO::getGid, hasShortLinkDO.getGid());
//                ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
//                shortLinkGotoMapper.deleteById(shortLinkGotoDO.getId());
//                shortLinkGotoDO.setGid(requestParam.getGid());
//                shortLinkGotoMapper.insert(shortLinkGotoDO);
//                LambdaUpdateWrapper<LinkAccessStatsDO> linkAccessStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkAccessStatsDO.class)
//                        .eq(LinkAccessStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkAccessStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkAccessStatsDO::getDelFlag, 0);
//                LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkAccessStatsMapper.update(linkAccessStatsDO, linkAccessStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkLocaleStatsDO> linkLocaleStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkLocaleStatsDO.class)
//                        .eq(LinkLocaleStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkLocaleStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkLocaleStatsDO::getDelFlag, 0);
//                LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkLocaleStatsMapper.update(linkLocaleStatsDO, linkLocaleStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkOsStatsDO> linkOsStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkOsStatsDO.class)
//                        .eq(LinkOsStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkOsStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkOsStatsDO::getDelFlag, 0);
//                LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkOsStatsMapper.update(linkOsStatsDO, linkOsStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkBrowserStatsDO> linkBrowserStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkBrowserStatsDO.class)
//                        .eq(LinkBrowserStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkBrowserStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkBrowserStatsDO::getDelFlag, 0);
//                LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkBrowserStatsMapper.update(linkBrowserStatsDO, linkBrowserStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkDeviceStatsDO> linkDeviceStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkDeviceStatsDO.class)
//                        .eq(LinkDeviceStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkDeviceStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkDeviceStatsDO::getDelFlag, 0);
//                LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkDeviceStatsMapper.update(linkDeviceStatsDO, linkDeviceStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkNetworkStatsDO> linkNetworkStatsUpdateWrapper = Wrappers.lambdaUpdate(LinkNetworkStatsDO.class)
//                        .eq(LinkNetworkStatsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkNetworkStatsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkNetworkStatsDO::getDelFlag, 0);
//                LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkNetworkStatsMapper.update(linkNetworkStatsDO, linkNetworkStatsUpdateWrapper);
//                LambdaUpdateWrapper<LinkAccessLogsDO> linkAccessLogsUpdateWrapper = Wrappers.lambdaUpdate(LinkAccessLogsDO.class)
//                        .eq(LinkAccessLogsDO::getFullShortUrl, requestParam.getFullShortUrl())
//                        .eq(LinkAccessLogsDO::getGid, hasShortLinkDO.getGid())
//                        .eq(LinkAccessLogsDO::getDelFlag, 0);
//                LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
//                        .gid(requestParam.getGid())
//                        .build();
//                linkAccessLogsMapper.update(linkAccessLogsDO, linkAccessLogsUpdateWrapper);
            } finally {
                rLock.unlock();
            }
        }

        // 如果修改了有效期类型或有效期时间或原始链接，就必须把GOTO缓存给删了  那么后面就会重新从数据库查询出来再看能不能访问
        if (!Objects.equals(hasShortLinkDO.getValidDateType(), requestParam.getValidDateType())
                || !Objects.equals(hasShortLinkDO.getValidDate(), requestParam.getValidDate())
                || !Objects.equals(hasShortLinkDO.getOriginUrl(), requestParam.getOriginUrl())) {
            stringRedisTemplate.delete(String.format(GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
            Date currentDate = new Date();
            // 如果 修改前的短链接已经过期了 那么就会在缓存中留下一个短链接不存在的标识
            if (hasShortLinkDO.getValidDate() != null && hasShortLinkDO.getValidDate().before(currentDate)) {
                // 继续判断
                // 如果当前修改后，有效期没过 或 永久有效 那么之前如果有短链接不存在标识的话，就要删掉该标识
                // 如果不删除的话，那么如果一开始这个短链接就过期了，在缓存中加入标识，那就算后面改成永久也是404
                if (Objects.equals(requestParam.getValidDateType(), VailDateTypeEnum.PERMANENT.getType()) || requestParam.getValidDate().after(currentDate)) {
                    stringRedisTemplate.delete(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
                }
            }
        }
    }

    @SneakyThrows
    @Override
    public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
        String serverName = request.getServerName();
        // 获取port
        String serverPort = Optional.of(request.getServerPort())
                // 80就不用拼接端口了
                .filter(each -> !Objects.equals(each, 80))
                .map(String::valueOf)
                .map(each -> ":" + each)
                .orElse("");
        String fullShortUrl = serverName + serverPort  + "/" + shortUri;

        // 先从缓存中获取originLink
        String originLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
        if(StrUtil.isNotBlank(originLink)){ // 不为空就直接跳
            // 记录一下短链接跳转记录
            ShortLinkStatsRecordDTO statsRecord = buildLinkStatsRecordAndSetUser(fullShortUrl, request, response);
            shortLinkStats(fullShortUrl, null, statsRecord);
            ((HttpServletResponse) response).sendRedirect(originLink);
            return;
        }

        // keypoint 避免短链接穿透
        // 1、判断布隆过滤器是否存在 因为短链接创建的时候会加入到布隆过滤器，所以布隆过滤器不太可能会误判
        boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
        if(!contains){ // 不存在直接重定向到页面不存在标识页面
            ((HttpServletResponse) response).sendRedirect("/page/notfound");
            return;
        }
        // 2、存在的话，再次从缓存中判断 看是否带有短链接不存在的标识
        String gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
        if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
            ((HttpServletResponse) response).sendRedirect("/page/notfound");
            return;
        }

        // keypoint 缓存为空的话，为了避免缓存击穿，也就是大量请求访问缓存过期的短链接 所以要使用分布式锁
        RLock lock = redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));
        lock.lock();
        try{
            // keypoint 为什么获取到锁后先查缓存是否存在，而不是先查空缓存是否存在？
            //  这涉及到一次无用网络 IO 的问题，因为如果空缓存不存在，那么缓存也不可能存在，
            //  如果把缓存放上面，那就证明我们假设系统中大量的访问都是正常的。反之，则是考虑系统会被大量攻击。

            // keypoint 双重判定，再获取一次看看缓存中是否有值，因为可能同时有很多请求都会过来，
            //  如果某个请求已经从数据库查出来放到缓存了，那么后面的请求都不用再走数据库了，所以这里
            //  再判定一次缓存是否有值，如果有就不用查数据库了，直接跳转
            originLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
            if(StrUtil.isNotBlank(originLink)){
                // 记录一下短链接跳转记录
                ShortLinkStatsRecordDTO statsRecord = buildLinkStatsRecordAndSetUser(fullShortUrl, request, response);
                shortLinkStats(fullShortUrl, null, statsRecord);
                ((HttpServletResponse) response).sendRedirect(originLink);
                return;
            }
            // 双重判定，再次判断是否该链接不存在
            gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
            if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
                ((HttpServletResponse) response).sendRedirect("/page/notfound");
                return;
            }
            // keypoint 这里有个问题，用户只能给我们传递shortUri，
            //  但是我们link表又是根据gid进行分表的，所以这里得引入路由表
            LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
                    .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
            ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
            if(shortLinkGotoDO==null){
                // 3、如果从数据库中查出来还是null 那么就设置短链接不存在标识 30s
                stringRedisTemplate.opsForValue()
                        .set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl),"-",30,TimeUnit.MINUTES);
                // 严谨来说此处需要进行风控
                ((HttpServletResponse) response).sendRedirect("/page/notfound");
                return;
            }
            LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                    .eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
                    .eq(ShortLinkDO::getFullShortUrl,fullShortUrl)
                    .eq(ShortLinkDO::getEnableStatus, 0)
                    .eq(ShortLinkDO::getDelFlag, 0)
                    .orderByDesc(ShortLinkDO::getCreateTime);
            ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
            // 如果短链接过期了或不存在，就当该短链接没有，直接设置一个不存在标识  这里有个细节是要判断validDate不为Null，因为永久有效的话该值是为Null的
            if(shortLinkDO==null || (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date()))) {
                stringRedisTemplate.opsForValue()
                        .set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
                ((HttpServletResponse) response).sendRedirect("/page/notfound");
                return;
            }
            // 放入缓存
            stringRedisTemplate.opsForValue()
                    .set(String.format(GOTO_SHORT_LINK_KEY,fullShortUrl),shortLinkDO.getOriginUrl()
                            , LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()),TimeUnit.MILLISECONDS);
            // 记录一下短链接跳转记录
            ShortLinkStatsRecordDTO statsRecord = buildLinkStatsRecordAndSetUser(fullShortUrl, request, response);
            shortLinkStats(fullShortUrl, shortLinkDO.getGid(), statsRecord);
            // 给该请求302重定向到原始链接
            ((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
        }finally {
            lock.unlock();
        }

    }

    @Override
    public ShortLinkBatchCreateRespDTO batchCreateShortLink(ShortLinkBatchCreateReqDTO requestParam) {
        List<String> originUrls = requestParam.getOriginUrls();
        List<String> describes = requestParam.getDescribes();
        List<ShortLinkBaseInfoRespDTO> result = new ArrayList<>();
        for (int i = 0; i < originUrls.size(); i++) {
            ShortLinkCreateReqDTO shortLinkCreateReqDTO = BeanUtil.toBean(requestParam, ShortLinkCreateReqDTO.class);
            shortLinkCreateReqDTO.setOriginUrl(originUrls.get(i));
            shortLinkCreateReqDTO.setDescribe(describes.get(i));
            try {
                // 调用生成单个短链接方法
                ShortLinkCreateRespDTO shortLink = createShortLink(shortLinkCreateReqDTO);
                ShortLinkBaseInfoRespDTO linkBaseInfoRespDTO = ShortLinkBaseInfoRespDTO.builder()
                        .fullShortUrl(shortLink.getFullShortUrl())
                        .originUrl(shortLink.getOriginUrl())
                        .describe(describes.get(i))
                        .build();
                result.add(linkBaseInfoRespDTO);
            } catch (Throwable ex) {
                log.error("批量创建短链接失败，原始参数：{}", originUrls.get(i));
            }
        }
        return ShortLinkBatchCreateRespDTO.builder()
                .total(result.size())
                .baseLinkInfos(result)
                .build();
    }

    /**
     * 构建短链接访问记录对象 以及 增加uv的cookie标识
     * 这里还存在一个问题，uv每次+1后，我们都存在内存里面存了一个标识，但是本来就多，还永久存储，容易撑爆内存
     */
    private ShortLinkStatsRecordDTO buildLinkStatsRecordAndSetUser(String fullShortUrl, ServletRequest request, ServletResponse response) {
        // 在lambda表达式中要用这个类型，不能用Boolean
        AtomicBoolean uvFirstFlag = new AtomicBoolean();
        Cookie[] cookies = ((HttpServletRequest) request).getCookies();
        AtomicReference<String> uv = new AtomicReference<>();
        Runnable addResponseCookieTask = () -> {
            // 因为要统计uv，那么一个用户只能有一个uv，所以这里需要判断一下cookie中是否有uv
            uv.set(UUID.fastUUID().toString());
            Cookie uvCookie = new Cookie("uv", uv.get());
            // 设置有效期为30天
            uvCookie.setMaxAge(60 * 60 * 24 * 30);
            // 根据 短域名 作为path标识
            uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.indexOf("/"), fullShortUrl.length()));
            ((HttpServletResponse) response).addCookie(uvCookie);
            // 第一次创建Cookie的话，UV肯定是第一次
            uvFirstFlag.set(Boolean.TRUE);
            // 要向缓存添加一下该uv的记录
            stringRedisTemplate.opsForSet().add(SHORT_LINK_STATS_UV_KEY + fullShortUrl, uv.get());
        };
        // 判断cookie是否为空
        if (ArrayUtil.isNotEmpty(cookies)) {
            // 判断是否本来就带有uv的cookie，如果带有就不用管，没有带的话，我们就手动加上cookie作为uv标识
            Arrays.stream(cookies)
                    .filter(each -> Objects.equals(each.getName(), "uv"))
                    .findFirst()
                    .map(Cookie::getValue)
                    .ifPresentOrElse(each -> {
                        // 如果cookie存在，
                        uv.set(each);
                        // keypoint 这里添加的时候 如果原来的集合中有该元素就返回0（添加失败）
                        //  ，如果没该元素就返回1（添加成功） 所以返回1就代表第一次新增，uv为第一次
                        Long uvAdded = stringRedisTemplate.opsForSet().add(SHORT_LINK_STATS_UV_KEY + fullShortUrl, each);
                        uvFirstFlag.set(uvAdded != null && uvAdded > 0L);
                    }, // 如果cookie不存在，就加上cookie
                            addResponseCookieTask);
        } else { // 为空就一定要加
            addResponseCookieTask.run();
        }
        // 获取用户真实IP
        String remoteAddr = LinkUtil.getActualIp(((HttpServletRequest) request));
        // 获取用户访问操作系统
        String os = LinkUtil.getOs(((HttpServletRequest) request));
        // 获取用户浏览器
        String browser = LinkUtil.getBrowser(((HttpServletRequest) request));
        // 获取用户访问设备
        String device = LinkUtil.getDevice(((HttpServletRequest) request));
        // 获取用户访问网络
        String network = LinkUtil.getNetwork(((HttpServletRequest) request));
        // 用的set数据结构 存在就不会加进去 加进去了就代表是首次访问
        Long uipAdded = stringRedisTemplate.opsForSet().add(SHORT_LINK_STATS_UIP_KEY + fullShortUrl, remoteAddr);
        boolean uipFirstFlag = uipAdded != null && uipAdded > 0L;
        return ShortLinkStatsRecordDTO.builder()
                .fullShortUrl(fullShortUrl)
                .uv(uv.get())
                .uvFirstFlag(uvFirstFlag.get())
                .uipFirstFlag(uipFirstFlag)
                .remoteAddr(remoteAddr)
                .os(os)
                .browser(browser)
                .device(device)
                .network(network)
                .currentDate(new Date())
                .build();
    }

    // 记录跳转记录New 直接发送到消息队列
    @Override
    public void shortLinkStats(String fullShortUrl, String gid, ShortLinkStatsRecordDTO statsRecord) {
        Map<String, String> producerMap = new HashMap<>();
        producerMap.put("fullShortUrl", fullShortUrl);
//        producerMap.put("gid", gid);
        producerMap.put("statsRecord", JSON.toJSONString(statsRecord));
        shortLinkStatsSaveProducer.send(producerMap);
    }

    // 记录跳转记录Old
    // keypoint 为什么要在这里加读锁？首先纠正一下读锁的概念，读锁是保证数据入库的正确性，而不是查询，
    //  其次之所以在这里加读锁，是避免短链接跳转的时候，要记录跳转数据，但是若刚好在这期间有人在修改
    //  短链接的gid，那么这里就暂时不能先统计数据，避免统计个错的gid，但是又不能让用户在这里干等着
    //  更新完，万一更新个300ms之类的都会很影响体验，所以这里就使用异步去统计数据
//    @Override
//    public void shortLinkStatsOld(String fullShortUrl, String gid, ShortLinkStatsRecordDTO statsRecord) {
//        fullShortUrl = Optional.ofNullable(fullShortUrl).orElse(statsRecord.getFullShortUrl());
//        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(String.format(LOCK_GID_UPDATE_KEY, fullShortUrl));
//        // 拿到读锁 这是个共享锁，大家都能拿的到 除非此时有人拿到写锁
//        RLock rLock = readWriteLock.readLock();
//        // 如果是没有使用MQ，才去使用延迟队列，有了MQ这段就不需要了
//        if (!rLock.tryLock()) {
//            // 这里使用的Redis的队列  当然后序可以换成MQ，不过有部署成本
//            // keypoint 之所以使用队列，是因为如果有人拿到写锁，那么读锁都访问不了，如果此时
//            //  让所有读锁都自旋等，不仅浪费时间，还可能让系统崩掉，这里直接return，使用队列
//            //  接着统计，然后每次队列重新统计都不会传递gid，这样每次该方法都去查最新的gid，
//            //  那么gid就永远是最新的，不会统计错的数据进去
//            delayShortLinkStatsProducer.send(statsRecord);
//            return;
//        }
//        try {
//            // 如果没有传递gid的话，就手动根据路由表查询出gid
//            if (StrUtil.isBlank(gid)) {
//                LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
//                        .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
//                ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
//                gid = shortLinkGotoDO.getGid();
//            }
//            // 获取当前小时和当前周
//            int hour = DateUtil.hour(new Date(), true);
//            Week week = DateUtil.dayOfWeekEnum(new Date());
//            int weekValue = week.getIso8601Value();
//            LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
//                    .pv(1)
//                    .uv(statsRecord.getUvFirstFlag() ? 1 : 0)
//                    .uip(statsRecord.getUipFirstFlag() ? 1 : 0)
//                    .hour(hour)
//                    .weekday(weekValue)
//                    .fullShortUrl(fullShortUrl)
//                    .gid(gid)
//                    .date(new Date())
//                    .build();
//            linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
//
//            // 下面是跟高德地图API相关的
//            Map<String, Object> localeParamMap = new HashMap<>();
//            localeParamMap.put("key", statsLocaleAmapKey);
//            localeParamMap.put("ip", statsRecord.getRemoteAddr());
//            // 通过高德地图API获取用户位置
//            String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap);
//            JSONObject localeResultObj = JSON.parseObject(localeResultStr);
//            String infoCode = localeResultObj.getString("infocode");
//            String actualProvince = "未知";
//            String actualCity = "未知";
//            // infoCode状态码 10000 代表 成功
//            if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) {
//                String province = localeResultObj.getString("province");
//                // 如果根据IP找不到省份，返回的是空，这里就单独判断一下
//                boolean unknownFlag = StrUtil.equals(province, "[]");
//                LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
//                        // 设置属性就行了
//                        .province(actualProvince = unknownFlag ? actualProvince : province)
//                        .city(actualCity = unknownFlag ? actualCity : localeResultObj.getString("city"))
//                        .adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode"))
//                        .cnt(1)
//                        .fullShortUrl(fullShortUrl)
//                        // 这里暂时写死，只支持国内
//                        .country("中国")
//                        .gid(gid)
//                        .date(new Date())
//                        .build();
//                // 插入地图表
//                linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
//            }
//
//            // 获取用户访问操作系统，并存入对应的表
//            LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
//                    .os(statsRecord.getOs())
//                    .cnt(1)
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .date(new Date())
//                    .build();
//            linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
//
//            // 统计用户浏览器
//            LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
//                    .browser(statsRecord.getBrowser())
//                    .cnt(1)
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .date(new Date())
//                    .build();
//            linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
//
//            // 统计用户设备
//            LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
//                    .device(statsRecord.getDevice())
//                    .cnt(1)
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .date(new Date())
//                    .build();
//            linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);
//
//            // 统计用户访问网络
//            LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
//                    .network(statsRecord.getNetwork())
//                    .cnt(1)
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .date(new Date())
//                    .build();
//            linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);
//
//            // 统计用户访问日志 log表
//            LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
//                    .user(statsRecord.getUv())
//                    .ip(statsRecord.getRemoteAddr())
//                    .browser(statsRecord.getBrowser())
//                    .os(statsRecord.getOs())
//                    .network(statsRecord.getNetwork())
//                    .device(statsRecord.getDevice())
//                    .locale(StrUtil.join("-", "中国", actualProvince, actualCity))
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .build();
//            linkAccessLogsMapper.insert(linkAccessLogsDO);
//
//            // 给t_link增加总的uv、uip、pv数量
//            baseMapper.incrementStats(gid, fullShortUrl, 1, statsRecord.getUvFirstFlag() ? 1 : 0, statsRecord.getUipFirstFlag() ? 1 : 0);
//            LinkStatsTodayDO linkStatsTodayDO = LinkStatsTodayDO.builder()
//                    .todayPv(1)
//                    .todayUv(statsRecord.getUvFirstFlag() ? 1 : 0)
//                    .todayUip(statsRecord.getUipFirstFlag() ? 1 : 0)
//                    .gid(gid)
//                    .fullShortUrl(fullShortUrl)
//                    .date(new Date())
//                    .build();
//            // 数据也要添加到今日表
//            linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO);
//        } catch (Throwable ex) {
//            log.error("短链接访问量统计异常", ex);
//        }
//    }


    // 生成唯一不重复的短链接后缀
    private String generatorSuffix(ShortLinkCreateReqDTO requestParam) {
        // 生成次数 之所以要有生成次数，是避免生成死循环
        int customGenerateCount = 0;
        String shortUri;
        while(true){
            if(customGenerateCount > 10){
                throw new ServiceException("短链接频繁生成，请稍后再试");
            }
            String originUrl = requestParam.getOriginUrl();
            // keypoint 每次冲突之后，让原本的链接加上UUID降低冲突概率，这里不能用毫秒，不然高并发是可能重复的
            originUrl += UUID.randomUUID().toString();
            shortUri = HashUtil.hashToBase62(originUrl);
            // 这里使用布隆过滤器判断短连接是否唯一   如果用数据库判断的话性能会差很多
            if(!shortUriCreateCachePenetrationBloomFilter.contains(createShortLinkDefaultDomain + "/" +shortUri)){
                break;
            }
            customGenerateCount++;
        }
        return shortUri;
    }

    // 获取网站图标
    @SneakyThrows
    private String getFavicon(String url) {
        URL targetUrl = new URL(url);
        HttpURLConnection connection = (HttpURLConnection) targetUrl.openConnection();
        connection.setRequestMethod("GET");
        connection.connect();
        int responseCode = connection.getResponseCode();
        if (HttpURLConnection.HTTP_OK == responseCode) {
            Document document = Jsoup.connect(url).get();
            Element faviconLink = document.select("link[rel~=(?i)^(shortcut )?icon]").first();
            if (faviconLink != null) {
                return faviconLink.attr("abs:href");
            }
        }
        return null;
    }

    // 校验是否为白名单
    private void verificationWhitelist(String originUrl) {
        Boolean enable = gotoDomainWhiteListConfiguration.getEnable();
        if (enable == null || !enable) {
            return;
        }
        // 获取域名
        String domain = LinkUtil.extractDomain(originUrl);
        if (StrUtil.isBlank(domain)) {
            throw new ClientException("跳转链接填写错误");
        }
        List<String> details = gotoDomainWhiteListConfiguration.getDetails();
        if (!details.contains(domain)) {
            throw new ClientException("演示环境为避免恶意攻击，请生成以下网站跳转链接：" + gotoDomainWhiteListConfiguration.getNames());
        }
    }

}
